Repository navigation
fix(plugin-auth): create-user reports the membership the store holds for the new account - #33587
Conversation
`membershipCreated` on POST /auth/admin/create-user (answer and audit row) now says the new account holds a membership after the call, whichever writer made the row during this creation. A `yielded` reconcile (the user.create.after bootstrap join bound the user first) and an existing row in the multi-org current organization both answer true; invite-only (`policy-skip`) still answers false with no organization. Claude-Session: https://claude.ai/code/session_016WV4d1NJYFGVi2FRztyBNo Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4d1NJYFGVi2FRztyBNo Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4d1NJYFGVi2FRztyBNo Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a23cf71b236ac3b0f8afe4cc51635fea96503213 && git checkout a23cf71b236ac3b0f8afe4cc51635fea96503213
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4bba8b8f02fdbdf30e9ad41fc5d2a6fdd3ccaf2a 026db556ca198f589ba14ed6e56c95498f11c27e && git checkout -B drift-repro 4bba8b8f02fdbdf30e9ad41fc5d2a6fdd3ccaf2a && git merge --no-ff 026db556ca198f589ba14ed6e56c95498f11c27e
node scripts/docs-audit/affected-docs.mjs --json 4bba8b8f02fdbdf30e9ad41fc5d2a6fdd3ccaf2a |
|
domain:server#5 · os-elon-musk · session_016WV4d1NJYFGVi2FRztyBNo Request: review · area A1 · Fixes #33339 · head 026db55 · requested at 2026-11-15T02:22Z Local (os-dev report, #33339 comment 6887872120): plugin-auth full suite 274 files / 5303 passed, 10 skipped; build + typecheck exit 0 (test-typecheck debt ledger re-recorded: 7 → 6); eslint exit 0; two ablations (single-org |
|
Contract-review-claim: session_016WV4d1PGptBW3QRQNmy5e6 · head 026db55 · 2026-11-15T02:23Z review#3. |
Review — PASSHead-sha: Read: the PR's three commits on top of Clause-②: The change is right.
Item 2. create-user writes no
Reviewed-by: |
|
domain:server#5 · os-elon-musk · session_016WV4d1NJYFGVi2FRztyBNo ACCEPT · area A1 · Fixes #33339 · head 026db55 · 2026-11-15T02:24Z
|
Fixes #33339
Clause-②: no
Metadata-protocol: none
维护者速读
POST /api/v1/auth/admin/create-user)的返回值与审计行里的membershipCreated,现在按"新账号在调用结束时是否已有组织成员关系"来回答,不再只看是不是本接口自己插入的那一行。sys_member,接口自己的补绑看到已有行就让路,于是回答false;数据库里明明有成员关系,管理员却被告知"创建了一个无组织的账号"。admin-user-endpoints.ts两行。仅邀请制(invite-only)仍回答false、不写成员行,测试钉住未变。packages/plugins/plugin-auth/src/admin-user-endpoints.ts(bindUserToSoleOrganization的返回行与joinCurrentOrganization的已存在分支);packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts(两条改写的钉子)。pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/admin-user-endpoints.test.ts,预期 100 条全绿,其中 "the bootstrap join already bound the new user" 一条断言返回与审计行都是true。What changes
membershipCreated, in the create-user answer and in theuser.admin_createdaudit row, now reports that the store holds a membership for the new account after the call, whichever writer made the row. The account did not exist before this call, so any membership it holds was created by this creation.bindUserToSoleOrganization):membershipCreatedisoutcome === 'bound' || outcome === 'yielded'. Ayieldedoutcome is the reconciler reading an existingsys_memberrow for the user; on create-user that row is theuser.create.afterbootstrap join's, written moments earlier inside the samecreateUser. This is the same reading the sign-up hook already makes (auth-manager-database-hooks.tstreatsboundandyieldedalike as "the user holds a membership").joinCurrentOrganization): the card asked whether the same reasoning applies to the "membership already there" branch. It does: the user is brand-new, so a row in the administrator's current organization can only have been written during this creation (a host'suser.create.afterhook). It is still respected (no secondaddMember) and is now reported asmembershipCreated: true.invite-onlyon the single-org posture answerspolicy-skip, noorganizationId,membershipCreated: false, nosys_memberinsert (the existing pin, unedited).failed,no-target-organdagent-accountstill answerfalse.bindUserToSoleOrganization,AdminAuditDecisions.membershipCreated,joinCurrentOrganization) now states the new one.Why two existing pins were rewritten. Both pinned
yielded/already-there asfalseby seeding asys_memberrow foruser-9before the call. For a brand-new account that seed can only stand for a row written during the creation, which is exactly the reported defect, so the old expectation pinned the bug.is idempotent when a membership already existsbecomesthe bootstrap join already bound the new user: …: the row is now written by thecreateUserdouble itself (the hook's bind, during the creation), and the pin assertstruein the answer AND the audit row, plus no secondsys_memberinsert.a membership already there is respected: … membershipCreated falsebecomesa membership written during the creation is respected: … membershipCreated true …, and gains the audit-row assertion.Item 2 of the card (business-unit member rows "not stamped"): premise does not hold on
main(4bba8b8), so nothing is changed for it.sys_business_unit_memberrow.git grep "'sys_business_unit_member'" -- 'packages/**/src/**/*.ts' ':!**/*.test.ts'at 4bba8b8 answers four hits: the object definition, thesys_userpage's related list, the CLI ownership inventory and the platform-object name list. No runtime code inserts that object; plugin-auth only reads it (external-membership.ts).warnOnEmptyUnitExpansion(sharing-rule-service.ts): "active business-unit rule expands to NO recipients … membership rows written by seed replay or by an elevated system write are not organization-stamped". It names two causes worth checking first; it does not say rows were written. The rule it named,showcase_share_new_inquiries_with_field_ops(organization null), points atbu_field_ops, and the showcase seed writes the unit tree but, by design, no user-to-unit membership (examples/app-showcase/src/data/seed/index.ts: "Users can't be seeded … so user↔unit membership … stay runtime admin actions"). An empty expansion on a fresh showcase boot is the true answer, and the warning reports it correctly.Tests
Run on the branch head before the PR opened (see Verification for the sha).
pnpm --filter @objectstack/plugin-auth test: Test Files 274 passed (274), Tests 5303 passed, 10 skipped (5313).pnpm --filter @objectstack/plugin-auth typecheck(after building the plugin-auth closure): the first run went red only oncheck:test-typecheckreporting the debt SHRANK (admin-user-endpoints.test.ts: 6 errors, ledger 7, because the rewritten pin reads calls throughcallsOf); re-recorded withgen:test-typecheck-debtand committed.eslint --no-inline-configon both changed source files: exit 0.Ablation (the fix committed first, each mutation through
scripts/ablation-replace.mjs, which verified the anchor 1 to 0 on disk and restored to the HEAD blobee8f0ff9d8c6withgit diff HEADempty; the test imports./admin-user-endpoints.jsfromsrc, so no build sits between):membershipCreated: result.outcome === 'bound',(the old rule):× the bootstrap join already bound the new user …, Tests 1 failed, 99 passed.existing.length > 0) return { organizationId, membershipCreated: false };(the old multi-org rule):× a membership written during the creation is respected …, Tests 1 failed, 99 passed.Acceptance notes
true), this call bound (binds the created user to the sole organization, unchanged,truein both), invite-only control (unchanged,false, no insert, no org lookup), multi-org already-there (truein both).failedoutcome after a unique-key race (another writer landed the pair between the reconciler's re-check and its insert) still answersfalsealthough the store then holds the row. Reading the store back after every create would close it at the cost of one more read per create; not done here, noted only.Verification
Local derived gate subset (
node scripts/pm/dispatch-gates.mjs --commands, reconciled with--ran) and every CI family: see the report comment on #33339 for the readings and the head they were taken against.Generated by Claude Code